Skip to content

fix(objectql): the boot-time governance audit resolves a SCOPED metadata plane the way a request does - #16610

Merged
os-musk merged 2 commits into
mainfrom
claude/issue-15252-scoped-metadata-audit-boot
Sep 7, 2026
Merged

fix(objectql): the boot-time governance audit resolves a SCOPED metadata plane the way a request does#16610
os-musk merged 2 commits into
mainfrom
claude/issue-15252-scoped-metadata-audit-boot

Conversation

@os-musk

@os-musk os-musk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15252

The C4 cell #14423's ruling left open: let the boot-time, non-request-scoped
action-governance audit obtain the scoped metadata service the way a request
would.

What was broken

ObjectQLPlugin.runGovernanceInventory acquired its metadata plane with
ctx.getService('metadata'). That accessor reads only the two synchronous
service maps — the kernel's own services, and PluginLoader.getServiceInstance
over serviceInstances. A ServiceLifecycle.SCOPED registration mints its
instances into scopedServices, keyed by scope id, so the call threw
Service 'metadata' is async - use await before loadMany, loadManyKeyed,
loadDiagnosed or load could run
. The wiring swallowed the throw into "no
metadata plane at all", and the ADR-0110 D5 inventory reported that scope's
action declarations as absent — silently, because an empty declaration set is
indistinguishable from a plane that holds nothing. On such a kernel a handler the
router dispatches was reported as "registered handler with NO declaration …
REFUSED at dispatch".

The router never had that gap: HttpDispatcher.resolveService asks
defaultKernel.getServiceAsync(name, scopeId) first, with the request's
environment id, and only then falls back to the synchronous accessors.

The fix, and why this shape

The card offered two shapes and did not choose: an awaited resolution, or an
injected reader the plugin resolves once. The deciding question it names is
which one makes the audit read the same instance the router resolves through.

Taken: the awaited resolution. ObjectQLPlugin.resolveGovernanceMetadataService
mirrors the router's own order — ctx.getServiceScoped('metadata', environmentId)
first, then the synchronous lookup. Both ctx.getServiceScoped(name, scopeId) and
the dispatcher's getServiceAsync(name, scopeId) land on
PluginLoader.getService, which caches per scope, so the audit holds the very
object the router dispatches from — asserted by toBe identity in the pin, not
by equality.

Why the injected reader was not taken. Resolving once and holding the result
is the wrong lifecycle for this seam on two counts. runGovernanceInventory runs
at kernel:ready and again on every metadata:reloaded, so a reader captured
earlier freezes the identity the audit reads while the kernel's own can move
(ObjectKernel.clearScope releases a scope's instances). And resolving before the
scope exists mints an instance beside the request path rather than the one on
it — precisely the identity failure the card is about. The awaited resolution asks
the same registry, at the same moment, that the router asks.

The pin, and its ablation

The card names its own acceptance bar: "Pin with a fixture that composes a scoped
metadata service and asserts the audit reports its declarations; ablation: the
synchronous read, the fixture goes red."

packages/objectql/src/plugin-governance-scoped-metadata.test.ts composes a real
ObjectKernel with a ServiceLifecycle.SCOPED metadata factory over a real
MetadataManager + DatabaseLoader, captures the kernel's real
PluginContext (so the accessor under test is the real one, not a transcription),
and runs the audit. Five cases: the declaration reported by name, the accusation
cleared, the instance identity, a regression guard for the statically registered
plane every shipped composition uses, and the boundary that remains.

Every assertion reads the audit's output. The pre-fix failure is silent, so
"no error was thrown" is exactly the state that already existed and pins nothing.

Ablation — the synchronous read restored (packages/objectql/src/plugin.ts
restored to the branch point, which is the pre-fix spelling; on-disk blob
proven equal to that commit's blob, marker hits 1 → 0, synchronous-read hits
0 → 1, then rebuilt):

mutated  (synchronous read)  vitest exit 1  —  Tests  3 failed | 2 passed (5)
             × REPORTS a declaration that exists only in the scoped plane, by name
             × does NOT accuse a handler whose only declaration lives in the scoped plane
             × reads the SAME instance the router resolves through, and mints no second one
restored (the fix)           vitest exit 0  —  Tests  5 passed (5)

The two cases that stay green under the ablation are the two that must: the
static-plane regression guard and the declared boundary. A "fix" that merely
silenced the audit would flip those too.

One measurement worth recording rather than smoothing over:
scripts/ablation-dist-preflight.mjs --absent exited 1 on the mutate leg,
reporting the marker surviving in dist/index.d.ts and dist/index.d.mts while
both executable artifacts (dist/index.js, dist/index.mjs) were correctly free
of it. That is the marker-uniqueness false red the script's own header documents:
the surviving hits are the prose in action-governance.ts's docblock, which names
ObjectQLPlugin.resolveGovernanceMetadataService and was not part of the
mutation. It does not bear on the ablation's colour either way — this fixture
resolves its subject from source (./plugin.js, same package), never through
dist/ — and the load-bearing proof is the on-disk blob identity above.

Recorded facts re-derived, not inherited

Verification

  • pnpm --filter @objectstack/objectql test — 285 files, 4806 tests, all pass.
  • pnpm --filter @objectstack/objectql typecheck — pass; the test-typecheck debt
    ledger is unmoved (44 files / 242 errors / 69 pinned signatures).
  • Gates derived with node scripts/pm/dispatch-gates.mjs --commands from the
    actual change set and reconciled with --ran: 65 derived, 65 run, 0
    NOT-MEASURED, 0 UNRUN
    . 63 green. Two answered PREREQUISITE NOT MET (exit 3,
    measured nothing, not a finding): check:dual-build-cjs-loads and
    check:type-check-debt both require a whole-workspace build that CI's lint job
    performs before the step. check:engine-split-ratio first refused on the
    shallow clone (exit 2); after git fetch --shallow-since it answered (98.1%).
  • check:engine-double-contract asked for the fixture's findOne double to be
    recorded and was answered with its own --write; the row landed in the
    pinned ledger, not the shrink-only baseline.

Acceptance notes

  • Out of scope, but this PR makes it stale — please route it.
    packages/runtime/src/action-governance-scope-divergence.test.ts's C4 case is
    still green and its assertions stay true (they exercise the kernel's raw
    synchronous accessor, not the plugin), but its prose now says the opposite of
    this tree: "the audit's lookup cannot see a scoped instance at all, and
    plugin.ts swallows the throw", "the assertions below pin the BOUNDARY", and
    "reaching a request-scoped instance from there is a different change with its
    own product decision, tracked on its own card" — that card is this one. Left
    untouched deliberately: the dispatch fenced this PR to
    packages/objectql/src/plugin.ts / action-governance.ts plus a fixture pin,
    with "stop on breach". A reader who trusts that docblock could revert this
    change, so it wants a follow-up in the runtime lane. The canonical statement of
    what is now closed and what remains lives in action-governance.ts's updated
    docblock.
  • The remaining boundary is stated in both docblocks and pinned as a case: a
    kernel serving several environments at once declares no single environmentId,
    so a boot-time audit has no scope to name. Auditing per environment is a
    different inventory with a different lifecycle.
  • Clause-② re-derived from the actual diff: no. Nothing exported moves — the
    new method is private on an already-exported class — and no accept set widens.
    Changeset disposition derived from the act: a behaviour fix in a released
    package, so a patch changeset for @objectstack/objectql, not
    skip-changeset.

Generated by Claude Code

…a request does

`ObjectQLPlugin.runGovernanceInventory` acquired its metadata plane with
`ctx.getService('metadata')`, which reads only the synchronous service maps.
A `ServiceLifecycle.SCOPED` registration mints into `PluginLoader.scopedServices`,
so the call threw `Service 'metadata' is async - use await` before any read
method ran, the wiring swallowed the throw into "no metadata plane at all", and
the audit reported that scope's declarations as absent.

Resolve it in the router's own order instead — `ctx.getServiceScoped('metadata',
environmentId)` first, then the synchronous lookup — so the audit holds the same
instance `HttpDispatcher.resolveService` hands the router.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…d its engine double

The fixture composes a SCOPED `metadata` service on a real ObjectKernel and
asserts the audit REPORTS that scope's declarations — the acceptance bar the
card names. Ablation (restore the synchronous read): 3 of its 5 cases go red.

`scripts/engine-double-contract.pinned.json` learns the file's `findOne` double,
written by `check-engine-double-contract --write` as the gate prescribed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/action-governance.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/action-governance.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 434ca2d64d2a82d368fc671e05e90dc1716e05bdpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 138b2e58ab0ae2d53b4b7b20db78a702551f1671 — the merge of head 8651eff7ca1a07d749e7cd201ca43ee6b9153ea5 into base 434ca2d64d2a82d368fc671e05e90dc1716e05bd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 138b2e58ab0ae2d53b4b7b20db78a702551f1671 && git checkout 138b2e58ab0ae2d53b4b7b20db78a702551f1671
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 434ca2d64d2a82d368fc671e05e90dc1716e05bd 8651eff7ca1a07d749e7cd201ca43ee6b9153ea5 && git checkout -B drift-repro 434ca2d64d2a82d368fc671e05e90dc1716e05bd && git merge --no-ff 8651eff7ca1a07d749e7cd201ca43ee6b9153ea5

node scripts/docs-audit/affected-docs.mjs --json 434ca2d64d2a82d368fc671e05e90dc1716e05bd

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 434ca2d64d2a82d368fc671e05e90dc1716e05bd → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants